Skip to content

feat(transcript): browse and search without disrupting playback - #72

Merged
trevormunoz merged 7 commits into
mainfrom
feat/transcript-reading-mode
Sep 10, 2026
Merged

trevormunoz merged 7 commits into
mainfrom
feat/transcript-reading-mode

Conversation

@trevormunoz

Copy link
Copy Markdown
Member

Browse and search without disrupting playback

Adds an opt-in reading mode so a listener can search, browse, or read another passage without accidentally moving playback. Additive and backward-compatible: with no new props, Transcript and <iiif-transcript-player> keep today's exact behavior (search-driven and scroll-driven seeking).

Public surface

Surface Addition Default
Transcript searchSeekBehavior: "change" | "activate" "change" (legacy)
Transcript scrollToSeek: boolean true
Transcript bindable readingMode: boolean false
Search / TranscriptSearch onmatchactivate, onqueryinput, onmatchnavigate —
<iiif-transcript-player> search-seek-behavior, scroll-to-seek, reading-mode attrs + properties, :state(browsing) same as Svelte
  • Following vs Browsing behind the single public readingMode. Following: playback may scroll the panel (and seek if scrollToSeek). Browsing: detached — playback still updates the active-passage highlight but never moves the panel, and scrolling never seeks.
  • One folded control. A single "Follow along" switch (default on) replaces the former auto-scroll-pause button; "Jump to current" appears only while Browsing and returns to Following by scrolling the passage at the media's current time into view (never seeks). Bare components expose semantics/state only (role="switch", aria-checked, data-following); the custom element ships a styled shadow pair and reflects reading-mode.
  • Recommended host config: searchSeekBehavior="activate", scrollToSeek={false}.

Concurrency

The scroll-driven seek actor (videoController) calls seekTo inside the actor after waitForReady resolves — before its promise resolves — so guarding the consuming transition is too late. The fix captures a policy epoch when the seek is queued and compares it immediately before seekTo; disabling scroll-to-seek or enabling reading mode mid-seek drops the pending seek, and re-enabling never replays it (e6fe65b).

How it was built

Three reviewable TDD commits, then a parallel adversarial review over the whole diff:

  • 29882c7 — separate search match updates from activation
  • e6fe65b — reading mode, the Follow along control, scroll-to-seek concurrency fix
  • 27dd4b8 — expose reading-mode configuration on the custom element
  • 2c8011a — address 5 review findings (activation control visibility while Browsing; canvas-switch query clearing; text-selection must not seek; prev/next scrolls match into view; polite mode-change announcement)
  • 27118e3 — README: document onmatchnavigate and control visibility

823 tests, typecheck, and lint pass. Behavioral cases from the spec's acceptance table are automated at the search, transcript/sync, and custom-element boundaries. Manual verification still recommended for keyboard-only use, screen-reader announcements/focus, text selection, touch scrolling, and reduced motion.

🤖 Generated with Claude Code

https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur

trevormunoz and others added 7 commits September 10, 2026 10:02
Adds explicit search-result activation ahead of the reading-mode work
(docs/specs/transcript-reading-mode.md, step 1 of 3):

- Search gains a synchronous onqueryinput(value) callback fired from the
  raw input event, and onmatchactivate(annotation, index) fired from
  Enter or a "Go to match" button (shown only when showActivation is
  true). Search stays playback-agnostic — it only renders the control
  and emits callbacks.
- TranscriptSearch owns playback wiring: its default onmatchactivate
  seeks once through a new handleMatchActivate transcript-context
  action; its default onqueryinput forwards to an optional
  handleQueryInput context action, the seam a later step fills in to
  enter Browsing. Either default is overridden by a supplied prop, same
  as onmatchchange.
- Transcript gains searchSeekBehavior ("change" | "activate", default
  "change"). In "change" mode, handleMatchChange keeps seeking on every
  match selection (legacy behavior, unchanged). In "activate" mode it
  only updates highlight/selection state; handleMatchActivate is the
  only seek path.
- Enter handles IME composition (compositionstart/end plus
  event.isComposing) and a debounce race: it commits the live input
  value before activating, so it never activates a stale match:
  selection resets to index 0 only when the committed query actually
  changes, preserving a browsed-to index when it hasn't.

Reading-mode controls, the sync concurrency fix, and custom-element
configuration are deferred to steps 2 and 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
…oll-to-seek concurrency fix

Implements step 2 of 3 of the transcript-reading-mode spec (docs/specs/
transcript-reading-mode.md): synchronization policy plus the reading and
return controls, building on step 1's search-activation split.

- Transcript gains a bindable readingMode (default false) and scrollToSeek
  (default true). readingMode doubles as the Following/Browsing state
  itself; the transcript context exposes it plus enterBrowsing/
  returnToFollowing actions for custom controls, panel-local by
  construction (each Transcript instance owns its own state).
- The former auto-scroll pause button is replaced by a single "Follow
  along" switch (role="switch", aria-checked, data-following) — turning it
  off enters Browsing, which stops both media->scroll and scroll->seek via
  two independent SyncController gates. A "Jump to current" button appears
  only while Browsing; returning (via the switch, the button, the context
  action, or a host property write) scrolls the passage at the media's
  CURRENT time into view — active cue, next cue when between cues, first/
  last at the boundaries, nothing with no annotations — without seeking.
  Focus moves from Jump to current to the switch only when the Jump button
  currently owns it; every other path preserves existing focus. The
  autoscrollPause/autoscrollResume i18n strings are superseded by
  followAlong/jumpToCurrent.
- Concurrency fix: videoController now takes a queuedEpoch captured at
  invoke time and a getCurrentEpoch() closure reading the live machine
  snapshot, checked immediately before seekTo() — the transition guard
  alone was too late, since the actor calls seekTo() itself before its
  promise resolves. syncMachine's scrollToSeekEnabled gate also blocks
  entering (and re-entering) scrollDriven outright while disabled, and
  disabling never replays once re-enabled since the dropped actor has
  already completed.
- Deliberate user scrolling enters Browsing when scrollToSeek is
  configured off (SyncController reports it via a new onUserScroll hook,
  after the same auto-scroll-echo suppression and throttle that already
  gate TRANSCRIPT_SCROLL); programmatic movement — returning, and any
  scrollToAnnotation call, including TranscriptSegments' own, now
  delegated through the transcript context when available — marks the
  scroll programmatic first so it can never be mistaken for that gesture.
- handleMatchChange now also checks !readingMode, so Browsing takes
  precedence over legacy "change"-mode seek-on-select; handleQueryInput
  (the seam step 1 left optional) now enters Browsing on a nonempty query
  in "activate" mode.

Covers the acceptance-gate rows assigned to this step: scroll-with-seek-
off, enable-reading-mode-while-seek-on, return inside-cue, return
between-cue, policy-off-mid-seek (+ no replay), two-panel independence,
return focus ownership vs host property write, and single-folded-control,
plus machine/actor-level coverage of the epoch check and a regression test
proving typing in "activate" mode now enters Browsing end-to-end.

Deferred to a later step (not required by this step's acceptance rows):
clearing the query and selection on canvas switch in "activate"/reading-
mode-active (needs a bindable query or context reset signal on Search,
same seam step 1 flagged); suppressing segment activation on a text-
selection pointer release. The custom element and docs are step 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
Step 3 of the transcript-reading-mode spec (docs/specs/transcript-reading-mode.md):
adds search-seek-behavior, scroll-to-seek, and reading-mode attributes/
properties to <iiif-transcript-player>, with the same defaults and
semantics as the Svelte Transcript component.

The element owns value-based parsing and reflection for scroll-to-seek
and reading-mode itself, through canonicalizeBooleanAttr plus a pair of
accessor overrides in extend() — not Svelte's presence-based
type:"Boolean" (which can't tell reading-mode="false" from "true") and
not Svelte-generated reflect:true (which would fight a manual write).
A user-driven readingMode change reflects back onto reading-mode through
a guarded, deferred write that compares against the live attribute and
skips when unchanged, so attributeChangedCallback cannot re-enter and
loop; reflection never touches playback.

The element exposes :state(browsing) (alongside the existing
:state(playing)/:state(loading)/:state(error)) and renders a styled
"Follow along" switch and "Jump to current" button in its shadow DOM —
Transcript.svelte already renders both bare; this is the element's own
skin, consistent with its other control-bar hooks.

Covers the "Custom element configured through markup and properties" and
the element half of "Single folded control across surfaces" acceptance-
gate rows: markup attributes, property writes, properties set before
upgrade, attribute removal, an invalid value's one-time host error,
scroll-to-seek="false" parsing to false, and browsing emitting no seeked
event. A final test walks the recommended configuration
(searchSeekBehavior="activate", scrollToSeek={false}) through the spec's
closing journey: listen, search, browse, activate, browse again, return.

Updates custom-elements.json and public-types.d.ts for the three new
attributes/properties and the browsing custom state, documents the
Transcript/TranscriptSearch additions and the recommended configuration
in the README, and updates the four docs-site demos to use
searchSeekBehavior="activate"/scrollToSeek={false} with a small styled
skin for the new Follow along switch and Jump to current button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
Fixes five gaps found in adversarial review of the transcript
reading-mode feature (docs/specs/transcript-reading-mode.md):

1. TranscriptSearch's showActivation now also renders while Browsing
   (readingMode=true) even in legacy searchSeekBehavior="change", so
   there's still an explicit way to jump to a match once Browsing
   suppresses seek-on-select. (Already staged; this adds test coverage.)
2. Canvas-switch query clearing: Transcript bumps a new
   queryResetSignal on annotation replacement in "activate" mode or
   while reading mode is active; Search honors a resetSignal prop by
   clearing its query/selection (and the visible input) without
   seeking. Legacy "change" mode with reading mode off is unaffected.
3. Segment no longer activates (seeks) on a click that ends a
   drag-to-select gesture — a non-collapsed text selection at click
   time suppresses onclick. Keyboard activation (Enter/Space) is
   unaffected.
4. Search gains onmatchnavigate, fired only from explicit prev/next
   navigation (never typing or activation); TranscriptSearch wires its
   default to scrollToAnnotation so browsing search results scrolls
   them into view.
5. Transcript announces Following/Browsing mode changes through the
   existing polite live region, using new
   transcript.followingAnnouncement / transcript.browsingAnnouncement
   i18n strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
Add the onmatchnavigate callback row to the Search/TranscriptSearch API
table and note that the activation control also appears while Browsing,
matching the shipped behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
… and legacy nav

- SyncController: replace the fixed 600ms programmatic-scroll suppression
  window with an explicit scroll transaction closed by `scrollend` (plus a
  1s fallback timer), so a smooth scroll that outlasts the old timeout no
  longer misclassifies its own echo as user input.
- TranscriptSegments: guard the custom `segment` snippet's `onclick` with
  the same text-selection check Segment.svelte already applies, via a new
  shared `isTextSelectionActive()` helper in transcript/utils.ts, so a
  drag-to-select release no longer activates a custom-snippet segment.
- TranscriptSearch: scope the default `onmatchnavigate` to scroll only in
  `activate` mode or while Browsing, restoring the legacy `change`-mode
  behavior (match change + seek via `onmatchchange`, no explicit
  programmatic scroll) for prev/next navigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
…xed window

The 1s fallback in beginProgrammaticScroll fired even mid-animation, so a
smooth scroll longer than 1s released suppression and its later echo events
were misread as user scrolls (seek / enter Browsing). Re-arm the fallback on
every echo scroll so it is a quiet-period settle detector: it can only elapse
once scrolling stops, never during an in-flight scroll, whatever its duration.
scrollend remains the primary close; the timer covers no-scrollend engines and
the target-already-in-view (no scroll) case. Also confirms overlapping
transactions collapse into one without a premature close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KfYGrLi7iJoevyVQsn5ur
@trevormunoz
trevormunoz merged commit 0cdcdcc into main Sep 10, 2026
1 check passed
@trevormunoz
trevormunoz deleted the feat/transcript-reading-mode branch September 10, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant